스트리밍 응답의 취소와 오류 처리
스트리밍 응답의 취소와 오류 처리
스트리밍은 첫 응답을 빠르게 보여 주지만 성공·실패·취소의 경계를 복잡하게 만든다. 브라우저 연결이 끊겨도 서버와 모델 생성은 계속될 수 있고, 텍스트는 중단할 수 있어도 이미 시작된 결제나 이메일 전송은 되돌릴 수 없다. 취소 신호를 전 구간에 전파하고, 부분 출력과 외부 부작용을 별도 상태로 기록하며, 재연결이 중복 실행을 만들지 않게 설계해야 한다.
목차
- #스트리밍은 문자열을 여러 조각으로 보내는 것 이상이다
- #완료와 실패와 취소를 구분하기
- #취소 신호를 호출 체인 전체로 전파하기
- #부분 출력은 완성된 답변이 아니다
- #도구 실행은 취소 가능성과 보상 가능성이 다르다
- #클라이언트 연결 종료를 서버에서 감지하기
- #SSE 스트림의 이벤트 프로토콜 설계
- #재연결과 중복 실행을 막기
- #백프레셔와 느린 클라이언트 처리
- #타임아웃을 한 종류로 두지 않기
- #예시 코드로 전체 흐름 구성하기
- #테스트해야 할 실패 시나리오
- #운영에서 관측할 지표
- #결론
- #관련 노트
스트리밍은 문자열을 여러 조각으로 보내는 것 이상이다
비스트리밍 요청은 성공 응답이나 오류 응답을 한 번 반환한다. 스트리밍에서는 이미 200 OK와 일부 텍스트를 보낸 뒤에도 오류가 발생할 수 있다. HTTP 상태 코드만으로 최종 결과를 표현할 수 없다.
요청 수신
-> 모델 연결 성공
-> 첫 토큰 전송
-> 420자 전송
-> 검색 도구 타임아웃
-> 스트림 종료
클라이언트가 받은 420자는 답변처럼 보이지만 서버 입장에서는 작업이 실패했다. 끝맺음이 없는 문장을 사용자가 사실로 오해할 수도 있다. 서버 로그에 단순히 status=200만 남으면 성공률도 잘못 집계된다.
스트리밍 요청에는 적어도 세 가지 채널이 동시에 존재한다.
- 텍스트나 구조화된 결과가 흐르는 데이터 채널
- 완료·오류·취소 상태를 알리는 제어 채널
- 사용자 연결 종료가 서버와 하위 작업으로 전달되는 취소 채널
이들을 하나의 문자열 스트림으로 취급하면 정상 종료와 네트워크 단절, 사용자 취소를 구분하기 어렵다.
완료와 실패와 취소를 구분하기
모든 중단을 error로 합치지 않는다.
| 상태 | 의미 | 대표 처리 |
|---|---|---|
| completed | 최종 이벤트까지 정상 전달 | 결과 확정 |
| client_aborted | 사용자가 중지하거나 연결 종료 | 하위 생성 취소 |
| deadline_exceeded | 서버가 정한 시간 초과 | 부분 결과 폐기 또는 제한 표시 |
| provider_failed | 모델 공급자 오류 | 조건부 재시도 |
| tool_failed | 도구 실행 실패 | 오류 설명 또는 복구 흐름 |
| policy_blocked | 안전 정책이 출력을 중단 | 정책 메시지로 종료 |
| delivery_lost | 서버는 완료했지만 전달 여부 불명 | 재조회 가능한 상태 보존 |
특히 client_aborted와 delivery_ambiguous는 다르다. 사용자가 명시적으로 중지 버튼을 눌렀다면 의도를 알 수 있다. 모바일 네트워크가 끊긴 경우에는 사용자가 결과를 원하지 않는다고 단정할 수 없다. 백그라운드 작업을 계속할지 취소할지는 기능 정책에 따라 결정해야 한다.
type StreamOutcome =
| { status: "completed"; finalEventId: string }
| { status: "client_aborted"; reason: "button" | "disconnect" }
| { status: "deadline_exceeded"; phase: StreamPhase }
| { status: "failed"; phase: StreamPhase; code: string }
| { status: "delivery_ambiguous"; lastEventId?: string };
취소 신호를 호출 체인 전체로 전파하기
브라우저에서 fetch를 취소했다고 서버의 모델 요청이 자동으로 취소되는 것은 아니다. 각 계층이 같은 취소 신호를 받거나 상위 신호에서 파생된 신호를 사용해야 한다.
flowchart LR
B[Browser AbortSignal] --> H[HTTP Handler]
H --> O[Orchestrator]
O --> M[Model Stream]
O --> R[Retriever]
O --> T[Tool Runner]
M --> C[Provider Connection]async function handleAnswerRequest(
request: Request,
response: Response,
) {
const controller = new AbortController();
request.on("aborted", () => {
controller.abort(new Error("CLIENT_DISCONNECTED"));
});
response.on("close", () => {
if (!response.writableEnded) {
controller.abort(new Error("RESPONSE_CLOSED_EARLY"));
}
});
await answerService.stream({
question: await readQuestion(request),
signal: controller.signal,
sink: createResponseSink(response),
});
}
하위 함수가 signal을 받기만 하고 실제 SDK 호출에 전달하지 않으면 취소는 동작하지 않는다.
async function streamModel(
input: ModelInput,
signal: AbortSignal,
) {
signal.throwIfAborted();
return modelClient.stream({
...input,
signal,
});
}
CPU 작업이나 동기 루프도 중간에 신호를 확인해야 한다.
for (const document of documents) {
signal.throwIfAborted();
await indexOne(document, { signal });
}
요청마다 abort 리스너를 추가하고 제거하지 않으면 장시간 실행되는 프로세스에서 누수가 생길 수 있다. 작업 종료 시 리스너를 해제하거나 once 옵션을 사용한다.
부분 출력은 완성된 답변이 아니다
토큰 조각을 그대로 DOM에 붙이면 사용자는 중간 텍스트도 확정된 답변으로 읽는다. 특히 JSON, Markdown 표, 코드 블록은 중간에 끊겼을 때 문법적으로 잘못될 수 있다.
환불 조건은 다음과 같습니다.
1. 결제 후 7일 이내
2. 사용량이
이 부분 출력만 남겨 두면 두 번째 조건이 무엇인지 알 수 없다. UI에는 스트리밍 중 상태를 표시하고 정상적인 done 이벤트를 받은 뒤에만 확정 상태로 바꾼다.
interface StreamingMessage {
id: string;
text: string;
state:
| "receiving"
| "complete"
| "cancelled"
| "failed"
| "reconnecting";
}
취소된 텍스트를 유지할지는 제품 선택이다.
- 글쓰기 보조처럼 부분 결과도 유용하면
중단된 응답으로 표시해 보존한다. - 정책·결제 안내처럼 불완전한 문장이 위험하면 접거나 폐기한다.
- 구조화 출력은 마지막 검증이 끝나기 전 업무 데이터로 저장하지 않는다.
- 코드 생성은 닫히지 않은 코드 블록임을 UI에서 명확히 표시한다.
수신 중인 토큰 버퍼와 사용자가 확정해 재사용할 메시지를 같은 상태로 저장하지 않는다. 완료 이벤트, 스키마 검증, 안전 검사 같은 커밋 조건을 둔다.
도구 실행은 취소 가능성과 보상 가능성이 다르다
모델 생성과 검색은 대체로 중간 취소가 가능하다. 외부 시스템 변경은 요청을 보낸 뒤 취소 신호가 와도 이미 완료되었을 수 있다.
stateDiagram-v2
[*] --> Proposed
Proposed --> Approved
Approved --> Executing
Executing --> Cancelled: 실행 전 취소 확인
Executing --> Succeeded: 외부 시스템 완료
Executing --> Unknown: 응답 전 연결 단절
Unknown --> Succeeded: 상태 재조회
Unknown --> Failed: 상태 재조회
Succeeded --> Compensating: 보상 요청
Compensating --> Compensated이메일 전송 API에 요청을 보낸 직후 사용자가 중지 버튼을 눌렀다고 하자. 화면에는 취소됨이라고 표시할 수 있어도 이메일이 발송되지 않았다고 보장할 수 없다. 이런 작업은 다음을 분리한다.
- 아직 시작하지 않은 작업의 취소
- 진행 중 요청의 중단 시도
- 완료 여부가 불명확한 상태
- 완료된 작업의 보상 작업
async function executeToolStep(step: ToolStep, signal: AbortSignal) {
signal.throwIfAborted();
const execution = await executionStore.create({
stepId: step.id,
idempotencyKey: step.idempotencyKey,
state: "executing",
});
try {
const receipt = await toolClient.execute(step.payload, {
signal,
idempotencyKey: step.idempotencyKey,
});
return executionStore.markSucceeded(execution.id, receipt);
} catch (error) {
if (isAmbiguousDelivery(error)) {
return executionStore.markUnknown(execution.id);
}
throw error;
}
}
Unknown 상태에서는 같은 요청을 무작정 다시 보내지 않는다. 멱등성 키로 조회하거나 외부 시스템의 최종 상태를 확인한다. 관련 설계는 에이전트 재시도와 멱등성과 이어진다.
클라이언트 연결 종료를 서버에서 감지하기
런타임과 프레임워크마다 close, aborted, cancel의 의미가 다르다. 정상적으로 응답을 끝낸 뒤에도 close 이벤트가 발생할 수 있으므로 writableEnded 같은 상태를 함께 본다.
웹 표준의 ReadableStream에서는 소비자가 취소할 때 cancel이 호출된다.
function createAnswerStream(input: AnswerInput): ReadableStream<Uint8Array> {
const controller = new AbortController();
const encoder = new TextEncoder();
return new ReadableStream({
async start(streamController) {
try {
for await (const event of generateAnswer(input, controller.signal)) {
streamController.enqueue(
encoder.encode(serializeEvent(event)),
);
}
streamController.close();
} catch (error) {
if (!controller.signal.aborted) {
streamController.error(error);
}
}
},
cancel(reason) {
controller.abort(reason);
},
});
}
실제 서버에서는 프록시와 로드밸런서가 연결 종료를 늦게 전달할 수 있다. 로컬 개발 환경에서 동작한다는 것만으로 충분하지 않다. 운영 경로에서 브라우저 탭 닫기, 모바일 네트워크 전환, 프록시 타임아웃을 테스트한다.
SSE 스트림의 이벤트 프로토콜 설계
텍스트 조각만 보내지 말고 이벤트 종류와 ID를 명시한다.
event: start
id: evt-101
data: {"messageId":"msg-42"}
event: delta
id: evt-102
data: {"text":"인덱스는 "}
event: citation
id: evt-103
data: {"claimId":"claim-7","evidenceIds":["ev-2"]}
event: done
id: evt-104
data: {"finishReason":"stop","usage":{"outputTokens":312}}
오류도 프로토콜 이벤트로 보낼 수 있다.
event: error
id: evt-105
data: {"code":"UPSTREAM_TIMEOUT","retryable":true}
이미 헤더를 보냈다면 HTTP 500으로 바꿀 수 없으므로 클라이언트가 error와 done을 구분해야 한다. 내부 예외 메시지나 프롬프트 원문을 그대로 전송하지 말고 안정적인 오류 코드와 사용자용 메시지를 나눈다.
done은 마지막 텍스트 조각과 다르다. 후처리와 사용량 수집, 인용 검증까지 성공한 뒤에만 보내는 커밋 표식으로 사용할 수 있다.
재연결과 중복 실행을 막기
SSE의 Last-Event-ID나 애플리케이션 커서를 사용하면 연결이 끊긴 뒤 놓친 이벤트를 다시 받을 수 있다. 이를 지원하려면 이벤트를 일정 시간 저장하고 ID 순서를 보장해야 한다.
async function resumeStream(
requestId: string,
afterEventId: string | undefined,
sink: EventSink,
) {
const events = await eventStore.readAfter(requestId, afterEventId);
for (const event of events) {
await sink.write(event);
}
const state = await requestStore.get(requestId);
if (state.status === "running") {
await eventBus.subscribe(requestId, sink);
}
}
재연결은 새 작업 시작이 아니다. 같은 requestId로 기존 작업을 구독해야 한다. 클라이언트가 재연결할 때 새 POST 요청을 보내면 모델 호출이나 도구 실행이 중복될 수 있다.
이벤트 전달은 보통 at-least-once가 현실적이므로 클라이언트는 eventId로 중복을 제거한다. delta를 두 번 붙이면 문장이 중복되기 때문이다.
백프레셔와 느린 클라이언트 처리
모델이 토큰을 만드는 속도보다 클라이언트가 읽는 속도가 느리면 서버 버퍼가 계속 커질 수 있다. 쓰기 함수의 반환값이나 desiredSize를 확인하고 배출을 기다린다.
async function writeWithBackpressure(
response: NodeJS.WritableStream,
chunk: string,
signal: AbortSignal,
) {
signal.throwIfAborted();
if (!response.write(chunk)) {
await Promise.race([
once(response, "drain"),
onceAborted(signal),
]);
}
}
버퍼 상한을 넘으면 연결을 종료하거나 생성 요청을 취소한다. 느린 소비자 한 명이 서버 메모리를 계속 점유하지 않게 한다. 압축이나 프록시 버퍼링이 토큰을 모았다 한꺼번에 보내 첫 토큰 시간을 악화시키는지도 확인해야 한다.
타임아웃을 한 종류로 두지 않기
30초 타임아웃 하나만 두면 어느 단계가 느렸는지 알 수 없고, 정상적인 긴 생성도 중단될 수 있다.
| 타임아웃 | 기준 |
|---|---|
| queue timeout | 실행 시작 전 대기 시간 |
| connect timeout | 공급자 연결 수립 시간 |
| first-token timeout | 첫 출력까지 시간 |
| idle timeout | 다음 이벤트가 오지 않는 시간 |
| total deadline | 전체 요청 최대 시간 |
| tool timeout | 개별 도구 실행 시간 |
상위 deadline보다 하위 작업의 deadline을 조금 짧게 잡아 오류 이벤트를 정리해서 보낼 시간을 남긴다.
const total = AbortSignal.timeout(45_000);
const combined = AbortSignal.any([clientSignal, total]);
const toolSignal = withDeadline(combined, 8_000);
await toolRunner.run(step, { signal: toolSignal });
재시도는 남은 deadline을 고려한다. 남은 시간이 500ms인데 2초 backoff를 기다리는 재시도는 의미가 없다.
예시 코드로 전체 흐름 구성하기
다음은 실제 프로젝트와 무관한 재구성 예시다.
async function streamAnswer(
input: AnswerInput,
clientSignal: AbortSignal,
sink: EventSink,
) {
const deadline = AbortSignal.timeout(45_000);
const signal = AbortSignal.any([clientSignal, deadline]);
const requestId = input.requestId;
await requestStore.markRunning(requestId);
await sink.send({ type: "start", requestId });
try {
const plan = await planner.create(input.question, { signal });
for await (const event of agent.run(plan, { signal })) {
signal.throwIfAborted();
if (event.type === "text_delta") {
await sink.send({
type: "delta",
eventId: await eventIds.next(requestId),
text: event.text,
});
}
if (event.type === "tool_result") {
await executionStore.record(event.execution);
}
}
const verified = await finalizeAndVerify(requestId, { signal });
await requestStore.markCompleted(requestId, verified);
await sink.send({
type: "done",
eventId: await eventIds.next(requestId),
finishReason: "stop",
});
} catch (error) {
const outcome = classifyStreamError(error, {
clientAborted: clientSignal.aborted,
deadlineAborted: deadline.aborted,
});
await requestStore.markTerminated(requestId, outcome);
if (sink.isWritable()) {
await sink.send({
type: "error",
eventId: await eventIds.next(requestId),
code: publicErrorCode(outcome),
retryable: isRetryable(outcome),
});
}
} finally {
await sink.close();
}
}
주의할 점은 finally에서 외부 실행을 취소되었다고 덮어쓰지 않는 것이다. 요청 스트림과 개별 도구 실행은 서로 다른 상태 기계를 가진다. 요청이 취소되어도 도구가 Succeeded일 수 있다.
테스트해야 할 실패 시나리오
정상적인 짧은 응답만 테스트하면 운영 문제를 잡지 못한다.
- 첫 토큰 전에 사용자가 취소한다.
- 여러 토큰을 받은 뒤 중지 버튼을 누른다.
- 브라우저가 예고 없이 연결을 끊는다.
- 프록시가 idle timeout으로 연결을 닫는다.
- 모델은 계속 생성하지만 클라이언트가 매우 느리다.
delta전송 뒤 후처리 검증이 실패한다.- 도구 요청을 보낸 뒤 응답 전에 취소된다.
- 도구는 성공했지만 결과 이벤트 전달이 실패한다.
- 재연결에서 마지막 이벤트가 한 번 더 전달된다.
- 서버가 재시작되어 실행 상태만 남는다.
it("연결 종료가 모델 생성까지 전파된다", async () => {
const model = new FakeStreamingModel();
const request = createStreamingRequest();
const running = server.handle(request);
await model.emit("first chunk");
request.disconnect();
await running;
expect(model.signal.aborted).toBe(true);
expect(await requestStore.status(request.id)).toBe("client_aborted");
});
도구 실행이 포함된 테스트에서는 “호출 횟수 1회”와 최종 상태를 함께 확인한다.
운영에서 관측할 지표
| 지표 | 의미 |
|---|---|
| time to first token | 사용자가 첫 출력을 보기까지 시간 |
| stream completion rate | done까지 도달한 비율 |
| client abort rate | 사용자 취소·연결 종료 비율 |
| abort propagation lag | 클라이언트 종료부터 공급자 중단까지 시간 |
| tokens after abort | 취소 뒤 추가 생성된 토큰 |
| partial response retained | 중단된 결과가 UI에 남은 비율 |
| tool unknown state rate | 실행 결과가 불명확한 비율 |
| reconnect success rate | 기존 작업에 정상 재구독한 비율 |
| duplicate event rate | 중복 이벤트가 관측된 비율 |
| slow consumer disconnect | 백프레셔로 종료된 연결 수 |
취소율이 높은 구간은 답변이 너무 길거나 첫 토큰 뒤 유용한 정보가 늦게 나오는 신호일 수 있다. 토큰 비용을 기능 단위로 관측하기의 비용 데이터와 합치면 취소 뒤 낭비된 토큰도 찾을 수 있다.
결론
스트리밍은 응답을 빠르게 보이게 하지만 작업을 단순하게 만들지는 않는다. HTTP 연결, 모델 생성, 검색, 도구 실행, 이벤트 저장은 서로 다른 완료와 취소 상태를 가진다.
안전한 구현의 핵심은 연결 종료를 하위 작업까지 전파하고, 부분 출력과 확정 결과를 구분하며, 취소할 수 없는 외부 부작용은 별도의 영속 상태와 멱등성 키로 관리하는 것이다.
정상적인 done 이벤트를 커밋 표식으로 사용하고, 재연결은 기존 작업을 구독하게 하며, 취소 뒤 생성된 토큰과 불명확한 도구 상태를 관측해야 한다. 그래야 스트리밍이 단순한 시각 효과가 아니라 신뢰할 수 있는 실행 프로토콜이 된다.